@j0hanz/superfetch 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -444,16 +444,17 @@ Set environment variables in your MCP client `env` or in the shell before starti
444
444
 
445
445
  ### Core Server Settings
446
446
 
447
- | Variable | Default | Description |
448
- | --------------- | -------------------- | --------------------------------------------------------------------------------- |
449
- | `HOST` | `127.0.0.1` | HTTP bind address |
450
- | `PORT` | `3000` | HTTP server port (1024-65535) |
451
- | `USER_AGENT` | `superFetch-MCP/2.0` | User-Agent header for outgoing requests |
452
- | `CACHE_ENABLED` | `true` | Enable response caching |
453
- | `CACHE_TTL` | `3600` | Cache TTL in seconds (60-86400) |
454
- | `LOG_LEVEL` | `info` | Logging level. Only `debug` enables verbose logs; other values behave like `info` |
455
- | `ALLOW_REMOTE` | `false` | Allow binding to non-loopback hosts (OAuth required) |
456
- | `ALLOWED_HOSTS` | (empty) | Additional allowed Host/Origin values (comma/space separated) |
447
+ | Variable | Default | Description |
448
+ | ---------------------- | -------------------- | --------------------------------------------------------------------------------- |
449
+ | `HOST` | `127.0.0.1` | HTTP bind address |
450
+ | `PORT` | `3000` | HTTP server port (1024-65535) |
451
+ | `USER_AGENT` | `superFetch-MCP/2.0` | User-Agent header for outgoing requests |
452
+ | `CACHE_ENABLED` | `true` | Enable response caching |
453
+ | `CACHE_TTL` | `3600` | Cache TTL in seconds (60-86400) |
454
+ | `LOG_LEVEL` | `info` | Logging level. Only `debug` enables verbose logs; other values behave like `info` |
455
+ | `ALLOW_REMOTE` | `false` | Allow binding to non-loopback hosts (OAuth required) |
456
+ | `ALLOWED_HOSTS` | (empty) | Additional allowed Host/Origin values (comma/space separated) |
457
+ | `TRANSFORM_TIMEOUT_MS` | `30000` | Worker transform timeout in milliseconds (5000-120000) |
457
458
 
458
459
  For HTTP server tuning (`SERVER_HEADERS_TIMEOUT_MS`, `SERVER_REQUEST_TIMEOUT_MS`, `SERVER_KEEP_ALIVE_TIMEOUT_MS`, `SERVER_SHUTDOWN_CLOSE_IDLE`, `SERVER_SHUTDOWN_CLOSE_ALL`), see `CONFIGURATION.md`.
459
460
 
package/dist/cache.js CHANGED
@@ -5,9 +5,7 @@ import { config } from './config.js';
5
5
  import { sha256Hex } from './crypto.js';
6
6
  import { getErrorMessage } from './errors.js';
7
7
  import { logDebug, logWarn } from './observability.js';
8
- function isRecord(value) {
9
- return typeof value === 'object' && value !== null;
10
- }
8
+ import { isRecord } from './utils.js';
11
9
  export function parseCachedPayload(raw) {
12
10
  try {
13
11
  const parsed = JSON.parse(raw);
@@ -47,21 +45,121 @@ const CACHE_HASH = {
47
45
  URL_HASH_LENGTH: 16,
48
46
  VARY_HASH_LENGTH: 12,
49
47
  };
50
- function stableStringify(value) {
51
- if (!isRecord(value)) {
52
- if (value === null || value === undefined) {
53
- return '';
48
+ const CACHE_VARY_LIMITS = {
49
+ MAX_STRING_LENGTH: 4096,
50
+ MAX_KEYS: 64,
51
+ MAX_ARRAY_LENGTH: 64,
52
+ MAX_DEPTH: 6,
53
+ MAX_NODES: 512,
54
+ };
55
+ function bumpStableStringifyNodeCount(state) {
56
+ state.nodes += 1;
57
+ return state.nodes <= CACHE_VARY_LIMITS.MAX_NODES;
58
+ }
59
+ function stableStringifyPrimitive(value) {
60
+ if (value === null || value === undefined) {
61
+ return '';
62
+ }
63
+ const json = JSON.stringify(value);
64
+ return typeof json === 'string' ? json : '';
65
+ }
66
+ function stableStringifyArray(value, state) {
67
+ if (value.length > CACHE_VARY_LIMITS.MAX_ARRAY_LENGTH) {
68
+ return null;
69
+ }
70
+ const parts = ['['];
71
+ let length = 1;
72
+ for (let index = 0; index < value.length; index += 1) {
73
+ if (index > 0) {
74
+ parts.push(',');
75
+ length += 1;
76
+ if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
77
+ return null;
78
+ }
79
+ const entry = stableStringifyInner(value[index], state);
80
+ if (entry === null)
81
+ return null;
82
+ parts.push(entry);
83
+ length += entry.length;
84
+ if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
85
+ return null;
86
+ }
87
+ parts.push(']');
88
+ length += 1;
89
+ return length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH ? null : parts.join('');
90
+ }
91
+ function stableStringifyRecord(value, state) {
92
+ const keys = Object.keys(value);
93
+ if (keys.length > CACHE_VARY_LIMITS.MAX_KEYS) {
94
+ return null;
95
+ }
96
+ keys.sort((a, b) => a.localeCompare(b));
97
+ const parts = ['{'];
98
+ let length = 1;
99
+ let isFirst = true;
100
+ for (const key of keys) {
101
+ const entryValue = value[key];
102
+ if (entryValue === undefined)
103
+ continue;
104
+ const encodedValue = stableStringifyInner(entryValue, state);
105
+ if (encodedValue === null)
106
+ return null;
107
+ const entry = `${JSON.stringify(key)}:${encodedValue}`;
108
+ if (!isFirst) {
109
+ parts.push(',');
110
+ length += 1;
111
+ if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
112
+ return null;
113
+ }
114
+ parts.push(entry);
115
+ length += entry.length;
116
+ if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
117
+ return null;
118
+ isFirst = false;
119
+ }
120
+ parts.push('}');
121
+ length += 1;
122
+ return length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH ? null : parts.join('');
123
+ }
124
+ function stableStringifyObject(value, state) {
125
+ if (state.stack.has(value)) {
126
+ return null;
127
+ }
128
+ if (state.depth >= CACHE_VARY_LIMITS.MAX_DEPTH) {
129
+ return null;
130
+ }
131
+ state.stack.add(value);
132
+ state.depth += 1;
133
+ try {
134
+ if (Array.isArray(value)) {
135
+ return stableStringifyArray(value, state);
54
136
  }
55
- return JSON.stringify(value);
137
+ return isRecord(value) ? stableStringifyRecord(value, state) : null;
56
138
  }
57
- if (Array.isArray(value)) {
58
- return `[${value.map((item) => stableStringify(item)).join(',')}]`;
139
+ finally {
140
+ state.depth -= 1;
141
+ state.stack.delete(value);
59
142
  }
60
- const entries = Object.entries(value)
61
- .filter(([, entryValue]) => entryValue !== undefined)
62
- .sort(([a], [b]) => a.localeCompare(b))
63
- .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
64
- return `{${entries.join(',')}}`;
143
+ }
144
+ function stableStringifyInner(value, state) {
145
+ if (!bumpStableStringifyNodeCount(state)) {
146
+ return null;
147
+ }
148
+ if (value === null || value === undefined) {
149
+ return '';
150
+ }
151
+ if (typeof value !== 'object') {
152
+ return stableStringifyPrimitive(value);
153
+ }
154
+ return stableStringifyObject(value, state);
155
+ }
156
+ function stableStringify(value) {
157
+ const state = {
158
+ depth: 0,
159
+ nodes: 0,
160
+ stack: new WeakSet(),
161
+ };
162
+ return stableStringifyInner(value, state);
65
163
  }
66
164
  function createHashFragment(input, length) {
67
165
  return sha256Hex(input).substring(0, length);
@@ -74,7 +172,16 @@ function buildCacheKey(namespace, urlHash, varyHash) {
74
172
  function getVaryHash(vary) {
75
173
  if (!vary)
76
174
  return undefined;
77
- const varyString = typeof vary === 'string' ? vary : stableStringify(vary);
175
+ let varyString;
176
+ if (typeof vary === 'string') {
177
+ varyString =
178
+ vary.length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH ? null : vary;
179
+ }
180
+ else {
181
+ varyString = stableStringify(vary);
182
+ }
183
+ if (varyString === null)
184
+ return null;
78
185
  if (!varyString)
79
186
  return undefined;
80
187
  return createHashFragment(varyString, CACHE_HASH.VARY_HASH_LENGTH);
@@ -84,6 +191,8 @@ export function createCacheKey(namespace, url, vary) {
84
191
  return null;
85
192
  const urlHash = createHashFragment(url, CACHE_HASH.URL_HASH_LENGTH);
86
193
  const varyHash = getVaryHash(vary);
194
+ if (varyHash === null)
195
+ return null;
87
196
  return buildCacheKey(namespace, urlHash, varyHash);
88
197
  }
89
198
  export function parseCacheKey(cacheKey) {
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+ export type TransformMetadataFormat = 'markdown' | 'frontmatter';
2
3
  interface AuthConfig {
3
4
  mode: 'oauth' | 'static';
4
5
  issuerUrl: URL | undefined;
@@ -40,6 +41,10 @@ export declare const config: {
40
41
  userAgent: string;
41
42
  maxContentLength: number;
42
43
  };
44
+ transform: {
45
+ timeoutMs: number;
46
+ metadataFormat: TransformMetadataFormat;
47
+ };
43
48
  cache: {
44
49
  enabled: boolean;
45
50
  ttl: number;
@@ -59,7 +64,7 @@ export declare const config: {
59
64
  };
60
65
  security: {
61
66
  blockedHosts: Set<string>;
62
- blockedIpPatterns: RegExp[];
67
+ blockedIpPatterns: readonly [RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp];
63
68
  allowedHosts: Set<string>;
64
69
  apiKey: string | undefined;
65
70
  allowRemote: boolean;
package/dist/config.js CHANGED
@@ -104,12 +104,19 @@ function parseLogLevel(envValue) {
104
104
  return 'info';
105
105
  return isLogLevel(level) ? level : 'info';
106
106
  }
107
+ function parseTransformMetadataFormat(envValue) {
108
+ const normalized = envValue?.trim().toLowerCase();
109
+ if (normalized === 'frontmatter')
110
+ return 'frontmatter';
111
+ return 'markdown';
112
+ }
107
113
  const SIZE_LIMITS = {
108
114
  TEN_MB: 10 * 1024 * 1024,
109
115
  };
110
116
  const TIMEOUT = {
111
117
  DEFAULT_FETCH_TIMEOUT_MS: 15000,
112
118
  DEFAULT_SESSION_TTL_MS: 30 * 60 * 1000,
119
+ DEFAULT_TRANSFORM_TIMEOUT_MS: parseInteger(process.env.TRANSFORM_TIMEOUT_MS, 30000, 5000, 120000),
113
120
  };
114
121
  function readCoreOAuthUrls() {
115
122
  return {
@@ -168,7 +175,9 @@ const ANY_V4 = buildIpv4([0, 0, 0, 0]);
168
175
  const METADATA_V4_AWS = buildIpv4([169, 254, 169, 254]);
169
176
  const METADATA_V4_AZURE = buildIpv4([100, 100, 100, 200]);
170
177
  const host = process.env.HOST ?? LOOPBACK_V4;
171
- const port = parseInteger(process.env.PORT, 3000, 1024, 65535);
178
+ const port = process.env.PORT?.trim() === '0'
179
+ ? 0
180
+ : parseInteger(process.env.PORT, 3000, 1024, 65535);
172
181
  const baseUrl = new URL(`http://${formatHostForUrl(host)}:${port}`);
173
182
  const allowRemote = parseBoolean(process.env.ALLOW_REMOTE, false);
174
183
  const runtimeState = {
@@ -197,6 +206,10 @@ export const config = {
197
206
  userAgent: process.env.USER_AGENT ?? 'superFetch-MCP/2.0',
198
207
  maxContentLength: SIZE_LIMITS.TEN_MB,
199
208
  },
209
+ transform: {
210
+ timeoutMs: TIMEOUT.DEFAULT_TRANSFORM_TIMEOUT_MS,
211
+ metadataFormat: parseTransformMetadataFormat(process.env.TRANSFORM_METADATA_FORMAT),
212
+ },
200
213
  cache: {
201
214
  enabled: parseBoolean(process.env.CACHE_ENABLED, true),
202
215
  ttl: parseInteger(process.env.CACHE_TTL, 3600, 60, 86400),
package/dist/fetch.js CHANGED
@@ -8,9 +8,7 @@ import { Agent } from 'undici';
8
8
  import { config } from './config.js';
9
9
  import { createErrorWithCode, FetchError, isSystemError } from './errors.js';
10
10
  import { getOperationId, getRequestId, logDebug, logError, logWarn, redactUrl, } from './observability.js';
11
- function isRecord(value) {
12
- return typeof value === 'object' && value !== null;
13
- }
11
+ import { isRecord } from './utils.js';
14
12
  function buildIpv4(parts) {
15
13
  return parts.join('.');
16
14
  }
@@ -368,23 +366,32 @@ function selectLookupResult(list, useAll, hostname) {
368
366
  function findLookupError(list, hostname) {
369
367
  return (findInvalidFamilyError(list, hostname) ?? findBlockedIpError(list, hostname));
370
368
  }
369
+ function normalizeAndValidateLookupResults(addresses, resolvedFamily, hostname) {
370
+ const list = normalizeLookupResults(addresses, resolvedFamily);
371
+ const error = findLookupError(list, hostname);
372
+ return { list, error };
373
+ }
374
+ function respondLookupError(callback, error, addresses) {
375
+ callback(error, addresses);
376
+ }
377
+ function respondLookupSelection(callback, selection) {
378
+ if (selection.error) {
379
+ callback(selection.error, selection.fallback);
380
+ return;
381
+ }
382
+ callback(null, selection.address, selection.family);
383
+ }
371
384
  function handleLookupResult(error, addresses, hostname, resolvedFamily, useAll, callback) {
372
385
  if (error) {
373
- callback(error, addresses);
386
+ respondLookupError(callback, error, addresses);
374
387
  return;
375
388
  }
376
- const list = normalizeLookupResults(addresses, resolvedFamily);
377
- const lookupError = findLookupError(list, hostname);
389
+ const { list, error: lookupError } = normalizeAndValidateLookupResults(addresses, resolvedFamily, hostname);
378
390
  if (lookupError) {
379
- callback(lookupError, list);
380
- return;
381
- }
382
- const selection = selectLookupResult(list, useAll, hostname);
383
- if (selection.error) {
384
- callback(selection.error, selection.fallback);
391
+ respondLookupError(callback, lookupError, list);
385
392
  return;
386
393
  }
387
- callback(null, selection.address, selection.family);
394
+ respondLookupSelection(callback, selectLookupResult(list, useAll, hostname));
388
395
  }
389
396
  function resolveDns(hostname, options, callback) {
390
397
  const { normalizedOptions, useAll, resolvedFamily } = buildLookupContext(options);
@@ -556,11 +563,64 @@ function publishFetchEvent(event) {
556
563
  // Avoid crashing the publisher if a subscriber throws.
557
564
  }
558
565
  }
559
- export function startFetchTelemetry(url, method) {
566
+ function buildContextFields(context) {
567
+ const fields = {};
568
+ if (context.contextRequestId) {
569
+ fields.contextRequestId = context.contextRequestId;
570
+ }
571
+ if (context.operationId) {
572
+ fields.operationId = context.operationId;
573
+ }
574
+ return fields;
575
+ }
576
+ function buildResponseMetadata(response, contentSize) {
577
+ const contentType = response.headers.get('content-type') ?? undefined;
578
+ const contentLengthHeader = response.headers.get('content-length');
579
+ const size = contentLengthHeader ??
580
+ (contentSize === undefined ? undefined : String(contentSize));
581
+ const metadata = {};
582
+ if (contentType)
583
+ metadata.contentType = contentType;
584
+ if (size)
585
+ metadata.size = size;
586
+ return metadata;
587
+ }
588
+ function logSlowRequest(context, duration, durationLabel, contextFields) {
589
+ if (duration <= 5000)
590
+ return;
591
+ logWarn('Slow HTTP request detected', {
592
+ requestId: context.requestId,
593
+ url: context.url,
594
+ duration: durationLabel,
595
+ ...contextFields,
596
+ });
597
+ }
598
+ function resolveSystemErrorCode(error) {
599
+ return isSystemError(error) ? error.code : undefined;
600
+ }
601
+ function buildFetchErrorEvent(context, err, duration, contextFields, status, code) {
602
+ const event = {
603
+ v: 1,
604
+ type: 'error',
605
+ requestId: context.requestId,
606
+ url: context.url,
607
+ error: err.message,
608
+ duration,
609
+ ...contextFields,
610
+ };
611
+ if (code !== undefined) {
612
+ event.code = code;
613
+ }
614
+ if (status !== undefined) {
615
+ event.status = status;
616
+ }
617
+ return event;
618
+ }
619
+ function createTelemetryContext(url, method) {
560
620
  const safeUrl = redactUrl(url);
561
621
  const contextRequestId = getRequestId();
562
622
  const operationId = getOperationId();
563
- const context = {
623
+ return {
564
624
  requestId: randomUUID(),
565
625
  startTime: performance.now(),
566
626
  url: safeUrl,
@@ -568,92 +628,55 @@ export function startFetchTelemetry(url, method) {
568
628
  ...(contextRequestId ? { contextRequestId } : {}),
569
629
  ...(operationId ? { operationId } : {}),
570
630
  };
631
+ }
632
+ export function startFetchTelemetry(url, method) {
633
+ const context = createTelemetryContext(url, method);
634
+ const contextFields = buildContextFields(context);
571
635
  publishFetchEvent({
572
636
  v: 1,
573
637
  type: 'start',
574
638
  requestId: context.requestId,
575
639
  method: context.method,
576
640
  url: context.url,
577
- ...(context.contextRequestId
578
- ? { contextRequestId: context.contextRequestId }
579
- : {}),
580
- ...(context.operationId ? { operationId: context.operationId } : {}),
641
+ ...contextFields,
581
642
  });
582
643
  logDebug('HTTP Request', {
583
644
  requestId: context.requestId,
584
645
  method: context.method,
585
646
  url: context.url,
586
- ...(context.contextRequestId
587
- ? { contextRequestId: context.contextRequestId }
588
- : {}),
589
- ...(context.operationId ? { operationId: context.operationId } : {}),
647
+ ...contextFields,
590
648
  });
591
649
  return context;
592
650
  }
593
651
  export function recordFetchResponse(context, response, contentSize) {
594
652
  const duration = performance.now() - context.startTime;
595
653
  const durationLabel = `${Math.round(duration)}ms`;
654
+ const contextFields = buildContextFields(context);
655
+ const responseMetadata = buildResponseMetadata(response, contentSize);
596
656
  publishFetchEvent({
597
657
  v: 1,
598
658
  type: 'end',
599
659
  requestId: context.requestId,
600
660
  status: response.status,
601
661
  duration,
602
- ...(context.contextRequestId
603
- ? { contextRequestId: context.contextRequestId }
604
- : {}),
605
- ...(context.operationId ? { operationId: context.operationId } : {}),
662
+ ...contextFields,
606
663
  });
607
- const contentType = response.headers.get('content-type');
608
- const contentLength = response.headers.get('content-length') ??
609
- (contentSize === undefined ? undefined : String(contentSize));
610
664
  logDebug('HTTP Response', {
611
665
  requestId: context.requestId,
612
666
  status: response.status,
613
667
  url: context.url,
614
668
  duration: durationLabel,
615
- ...(context.contextRequestId
616
- ? { contextRequestId: context.contextRequestId }
617
- : {}),
618
- ...(context.operationId ? { operationId: context.operationId } : {}),
619
- ...(contentType ? { contentType } : {}),
620
- ...(contentLength ? { size: contentLength } : {}),
669
+ ...contextFields,
670
+ ...responseMetadata,
621
671
  });
622
- if (duration > 5000) {
623
- logWarn('Slow HTTP request detected', {
624
- requestId: context.requestId,
625
- url: context.url,
626
- duration: durationLabel,
627
- ...(context.contextRequestId
628
- ? { contextRequestId: context.contextRequestId }
629
- : {}),
630
- ...(context.operationId ? { operationId: context.operationId } : {}),
631
- });
632
- }
672
+ logSlowRequest(context, duration, durationLabel, contextFields);
633
673
  }
634
674
  export function recordFetchError(context, error, status) {
635
675
  const duration = performance.now() - context.startTime;
636
676
  const err = error instanceof Error ? error : new Error(String(error));
637
- const event = {
638
- v: 1,
639
- type: 'error',
640
- requestId: context.requestId,
641
- url: context.url,
642
- error: err.message,
643
- duration,
644
- ...(context.contextRequestId
645
- ? { contextRequestId: context.contextRequestId }
646
- : {}),
647
- ...(context.operationId ? { operationId: context.operationId } : {}),
648
- };
649
- const code = isSystemError(err) ? err.code : undefined;
650
- if (code !== undefined) {
651
- event.code = code;
652
- }
653
- if (status !== undefined) {
654
- event.status = status;
655
- }
656
- publishFetchEvent(event);
677
+ const contextFields = buildContextFields(context);
678
+ const code = resolveSystemErrorCode(err);
679
+ publishFetchEvent(buildFetchErrorEvent(context, err, duration, contextFields, status, code));
657
680
  const log = status === 429 ? logWarn : logError;
658
681
  log('HTTP Request Error', {
659
682
  requestId: context.requestId,
@@ -661,10 +684,7 @@ export function recordFetchError(context, error, status) {
661
684
  status,
662
685
  code,
663
686
  error: err.message,
664
- ...(context.contextRequestId
665
- ? { contextRequestId: context.contextRequestId }
666
- : {}),
667
- ...(context.operationId ? { operationId: context.operationId } : {}),
687
+ ...contextFields,
668
688
  });
669
689
  }
670
690
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
package/dist/http.d.ts CHANGED
@@ -15,8 +15,14 @@ interface McpRequestBody {
15
15
  id?: string | number;
16
16
  params?: McpRequestParams;
17
17
  }
18
- export declare function startHttpServer(): Promise<{
18
+ export declare function startHttpServer(options?: {
19
+ registerSignalHandlers?: boolean;
20
+ }): Promise<{
19
21
  shutdown: (signal: string) => Promise<void>;
22
+ stop: () => Promise<void>;
23
+ url: string;
24
+ host: string;
25
+ port: number;
20
26
  }>;
21
27
  export declare function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void;
22
28
  export declare function normalizeHost(value: string): string | null;
@@ -55,6 +61,8 @@ export declare function ensureSessionCapacity({ store, maxSessions, res, evictOl
55
61
  res: Response;
56
62
  evictOldest: (store: SessionStore) => boolean;
57
63
  }): boolean;
64
+ type CloseHandler = (() => void) | undefined;
65
+ export declare function composeCloseHandlers(first: CloseHandler, second: CloseHandler): CloseHandler;
58
66
  export declare function resolveTransportForPost({ res, body, sessionId, options, }: {
59
67
  res: Response;
60
68
  body: Pick<McpRequestBody, 'method' | 'id'>;